plans: annual pricing tiers (hobby_yearly / pro_yearly / team_yearly) + billing.go plan_frequency (P2)#44
Merged
Conversation
… + billing.go plan_frequency (P2)
Adds the three yearly plan variants to plans.yaml and threads a new
plan_frequency field through POST /api/v1/billing/checkout. Limits +
features on each *_yearly plan mirror the monthly counterpart byte-for-byte;
only price (annual amount in cents) and billing_period: yearly differ.
## Pricing
hobby_yearly: $90/yr (~$7.50/mo, saves $18/yr vs $9 x 12)
pro_yearly: $490/yr (~$40.83/mo, saves $98/yr vs $49 x 12)
team_yearly: $1990/yr (~$165.83/mo, saves $398/yr vs $199 x 12)
## API changes
- `checkoutRequest.PlanFrequency` ("monthly" | "yearly", default monthly).
Empty stays back-compat with pre-P2 dashboard clients.
- `razorpayPlanIDFor(tier, frequency)` resolves the right plan_id from
config. Yearly env vars empty -> 503 billing_not_configured (so partial
rollout — monthly live, yearly plans not yet created on Razorpay — is
safe).
- `planIDToTier()` recognises both monthly and yearly plan_ids and maps
them back to the canonical (bare) tier. teams.plan_tier always stores
the canonical name so limits resolution is cycle-agnostic.
- Webhook unchanged in shape — the upgrade tier comes from the resolved
canonical tier regardless of which cycle paid.
- OpenAPI schema documents plan_frequency on /api/v1/billing/checkout.
## New env vars (operator action — see PR body callout)
RAZORPAY_PLAN_ID_HOBBY_YEARLY
RAZORPAY_PLAN_ID_PRO_YEARLY
RAZORPAY_PLAN_ID_TEAM_YEARLY
## Tests
- Unit: 5 new handler tests (invalid_frequency 400, yearly unconfigured
503, monthly default behaviour, team-tier guard fires on both cycles,
webhook plan_id -> canonical tier mapping for all 6 plan_ids).
- Unit: 3 new plans tests (yearly variants mirror monthly, CanonicalTier
helper, base-tier limits unchanged).
- `make test-unit`: green across all packages.
## Dependency
Requires `InstaNode-dev/common#pricing/p2-annual-plans` (PR #6) — adds
the `BillingPeriod` Plan field, the `Registry.BillingPeriod` method,
the `CanonicalTier` helper, and the three yearly entries in the
embedded defaultYAML. This api branch's `go.mod` `replace` directive
points to `../common` so local builds work, but the merged base in
`common` must be cut before this PR lands.
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
plans.yaml:hobby_yearly($90/yr ≈ $7.50/mo, saves $18/yr vs $9 × 12),pro_yearly($490/yr ≈ $40.83/mo, saves $98/yr vs $49 × 12),team_yearly($1990/yr ≈ $165.83/mo, saves $398/yr vs $199 × 12). Limits + features mirror the monthly counterpart byte-for-byte; only price andbilling_period: yearlydiffer.plan_frequency("monthly" | "yearly", default monthly) throughPOST /api/v1/billing/checkout. Empty stays back-compat. Yearly with noRAZORPAY_PLAN_ID_*_YEARLYset returns 503billing_not_configuredso partial rollout is safe.planIDToTier()recognises both cycles' plan_ids and maps them back to the canonical tier soteams.plan_tierstays cycle-agnostic.Until the operator creates the three Razorpay yearly plans in the Razorpay dashboard and sets the following keys on the
instant-secretsk8s Secret, the yearly toggle returns 503:RAZORPAY_PLAN_ID_HOBBY_YEARLYRAZORPAY_PLAN_ID_PRO_YEARLYRAZORPAY_PLAN_ID_TEAM_YEARLYMonthly pricing is unaffected by this rollout — existing
RAZORPAY_PLAN_ID_HOBBY/_PRO/_TEAMkeys keep working.Dependency
Requires
InstaNode-dev/common#6(pricing/p2-annual-plans) — adds theBillingPeriodPlan field,Registry.BillingPeriodmethod,CanonicalTierhelper, and the three yearly entries in the embeddeddefaultYAML. Local builds work viago.modreplace ../common, but the common PR must merge first for CI to be green on a fresh tree.Test plan
make test-unit— all packages green (incl. 22.9s handler suite)CanonicalTierstrips suffix, base-tier behaviour unchangedgo build ./...andgo vet ./...clean/api/v1/billing/checkoutwith{plan: pro, plan_frequency: yearly}and verify subscription opens at the yearly rate🤖 Generated with Claude Code